Перейти к основному содержимому

Интеграционное тестирование веб-приложения на инъекции

· 6 мин. чтения

Если у вас есть веб-приложение и вы задались тем что-бы идеально его покрыть тестами, то вот что у вас должно быть:

  • unit-тесты бэкэнда — в основном покрываются модели, генерируется покрытие — получаете необходимость изолировать модели (заодно single responsibility principle выполняется)
  • unit-тесты frontend — карма + phantomjs прокрутят все ваши angular-сервисы и backbone-модели — тоже приходится изолировать код
  • e2e (сценарные, системные) тесты — наверняка основанный на selenium (protractor, selenide). Медленно тестируется функционал работающей системы из UI — приходится задумываться о том что пользователь вообще делает (use cases)
  • db/entity тесты миграций — "с нуля" запускают изменения в БД и когда всё готово - сравнивают с entity/record классами для синхронизации кода с БД (так находятся лишние свойства и недостающие )
  • тестирования db-процедур я не рассматриваю, потому что PL/SQL не увлекаюсь

  • интеграционные тесты внешних систем/api - любого типа (rest, ftp, soap) и источника (соц.сети, бухгалтерия, склад, SMS-gateway), тестируют на

  • доступность (а-ля pingdom)

  • предсказуемый формат (банальный get и проверка json)

  • полное взаимодействие с записью (обычно партнёрская компания с разработчиком ставит тестовую машинку)

  • нагрузочные тесты (load, stress) тестируют всю систему что-бы определить максимальное число подключённых клиентов

  • тесты производительности (performance) тестируют эффективность использования памяти, CPU, сети, HDD IO в среднем при разных запросах что-бы выявить какие конкретно области приложения медленные и в что можно улучшить

  • интеграционные тесты контроллеров/api — запускающиеся без браузера, через CURL запросы, эмулирующие вызов из javascript или мобильных приложений

  • простые get - запросы, проверяющие на наличие ошибок/stacktrace

  • post/put запросы, меняющие данные

  • в запущенных случаях (мобильные приложения), когда с мобильника e2e тесты не запустить, а функционал надо тестировать, то получаются последовательные сценарные (а не одинарные get-post) запросы, сохраняющие состояние сущностей и пользователя (в БД и сессии)

Вот на предпоследних я немножко и остановлюсь

Unit-тестирование контроллеров неудобно

Тестировать контроллеры с помощью юнит-тестов, хоть и быстро исполняется в phpunit, пишется очень с большим трудом. Да, я слышал Боба Мартина что код надо полностью покрывать, но контроллер это место сосредоточения нескольких сущностей:

  • получение конфигурации (из php-include, yaml, БД, констант и проч) — значит надо либо исполнять всю загрузку системы, либо мокать, либо заменять константы/значения
  • создание instance новых моделей – значит надо мокать
  • вызовы глобальных IO-переменных/методов - значит надо их рефакторить, убирать в нетестируемые модели и мокать в контроллерах
  • глобальные переменные, Factory-вызовы, статика - как и с конфигурацией, всё сложно
  • аспекты - аннотации, доступ, логгинг + логика системы основанная на reflection — совсем какой-то магический мокинг должен быть. Например определение шаблона в аннотациях к методу контроллера..

Но хуже всего конечно не в самом мокании, а в количестве. Когда у вас метод контроллера использует 5 моделей, то вам надо столько же моков определить. А потом на каждую строчку поведения модели написать порой и не одну строчку эмулирования её поведения — какой и сколько раз метод вызвался, с какими аргументами, что вернулось. А порой ведь мок может вернуть обьект (как в PDO - PDOStatement например) и вызвать у него метод. Получается что моки надо связывать между собой. Я часто ещё и путаюсь в каком порядке их надо регистрировать, ведь вызов тестируемой функции в коде теста должно быть внизу, а регистрация моков в phpunit - до неё, фактически получается что код теста пишется задом наперёд. Короче — писать юнит-тесты для контроллеров опасно. (Впрочем некоторые советуют глянуть на phpspec)

Как стоит тестировать контроллеры

Интеграционные же тесты в чём-то схожи с e2e тестами, но они не включают в себя UI.

  1. Пишем класс с CURL- запросами (get,post.. при необходимости delete и put, если ваш api их использует)

  2. Решаем вопрос с авторизацией, если она есть (я использую сохранение сессии в cookie-файл) - CURL это поддерживает

  3. Пишем зависимость всех тестов от login-теста, что-бы не насиловать сервер если авторизация провалилась

  4. Простые get-запросы с существующими в БД id-шками должны вам будут сказать если где-то закралась ошибка, которую пропустили unit-тесты

Теперь POST/PUT - они чаще содержат ошибки на безопасность, потому что параметров и логики при изменении больше. Добавление и изменения сущностей должно возвращать какой-то результат. Скажем {result:1, id:3} в JSON скажет что обьект создался, id такой-то. Кроме обычных тестов на сохранение, надо попробовать во все возможные поля запихнуть sql-инъекцию и XSS. 

SQL-инъекции должны либо выдать сразу ошибку, либо при чтении entity, переданное значение (скажем 1' OR 1=1) будет отличаться от сохранённого в БД (в данном случае, возможно "1"). Иногда, из-за приведения типов, строка станет int-значением и это тоже надо считать как ok.

С XSS чуть сложней - должен быть браузер. Я решаю это так, что e2e тесты запускаются после интеграционных и reset БД не происходит, а значит навигация по системе, где недавно внедрены alert-ы, должно сломать e2e тесты. Список атакующих XSS токенов есть на OWASP.

Для автоматизации я пишу trait для PHPUnit-тестов, потому что так проще всего использовать один и тот же код в разных тестах. 

trait SQLinjection {
private $AttackTokens = [
'1" OR 1=1'
];

public function checkInfectedUpdate($saveURL,$readURL,$fields,callable $comparisonFn){
foreach($this->attackTokens as $injection){
$data = $fields;
foreach($fields as $k=>$v){
$data[$k]=($v=='*' ? $injection : $v);
}

$saveResult = $this->post($saveURL, $data);
$getResult = $this->get($getURL);

$comparisonFn($injection, $getResult, $saveResult);
}
}

public function checkInfectedInsert(..){..}

}

 

Каждый интеграционный тест наследует IntegrationBaseTest - в котором определены CURL-обёртки и путь к серверу. Метод теста сам должен решать как сравнивать результат с инъекцией, потому что иногда результат от вызова из API get() отличается в зависимости от entity - где-то это простой массив, а где-то иерархия со списками, по которым ещё надо пройтись (и скажем вычленить последнюю версию)

InvoiceControllerTest extends IntegrationBaseTest {
private $phpErrorDetection = 'error';

use SQLinjection;

/**
* @test
*/
function login() {
$result = parent::login();
}

/**
* @test
* @depends login
* @group security
*/
function postSave_AddingInjection() {
$self = $this;

$this->checkInfectedUpdate(
$this->baseURL . 'invoice/save',
$this->baseURL . 'invoice/get?id=3',
[
'company_id' => '1',
'title' => '*',
'description' => '*'
],
function ($injection, $getResponse, $insertResponse) use ($self) {
$this->assertEquals($injection, $getResponse['result']['title']);
$this->assertEquals($injection, json_decode($getResponse['result']['description']));
}
);
}
}

 

По мере того, как вы пишете тесты для контроллеров, получается что заодно вам приходится

  1. тестировать привилегии (кто может получить ответ?)
  2. рефакторить код контроллера, вынося логику в модели (потому что сложно понять толстые контроллеры)
  3. избавляться от stacktrace - потому что это выдаёт лишнюю информацию
  4. решать случаи с integrity violation, когда вы пытаетесь скажем добавить entity без проверки его связи с другими entity в БД

Описанный мною вариант не решает вопросов с CSRF, редиректами, несолёными паролями, SSL, сессиями и ошибками конфигурации сервера, но зато улучшает функции приложения по безопасности/логичности, даже если у вас всюду используется безопасный PDO с bindParam().

См. также ZAP